Skip to content

背景

在 Vue3 + Pinia 的大型项目中,随着业务复杂度增长,单一的巨型 store 会带来:

  • 状态耦合:无关状态变化导致组件不必要的重渲染
  • 维护困难:数千行代码的 store 难以理解和修改
  • 协作冲突:多人同时修改同一个 store 容易产生冲突
  • 测试复杂:巨大的状态面导致单测编写困难

大厂的实践是:"按领域/功能切分多个小 store + 聚合门面(facade)",而不是做"嵌套子 store(Pinia 不支持树状嵌套)"。


主流实践模式

1. 按领域拆分(Domain-first)

例如 account/symbol/trade.tabs/trade.orders/trade.positions/user/security/ 等,每个 store 只关心自己的状态与动作。

好处

  • ✅ 边界清晰、并行开发容易
  • ✅ 单测成本低
  • ✅ 状态变更影响范围可控

2. 门面/聚合层(Facade/Aggregator)

trade/ 下提供一个 useTradeStore只读转发与跨模块编排(不写具体业务),对外保持原有 API,便于渐进式重构。

好处

  • ✅ 外部调用点无需大改
  • ✅ 内部可持续拆分
  • ✅ 提供统一的业务入口

3. 按"页面/功能"配对 store(Feature-scoped)

页面有独立的 feature store(如 trade.tabs),同时复用领域 store(如 accountsymbolInfo)。

好处

  • ✅ 页面复杂交互不污染领域 store
  • ✅ 功能模块可以独立开发和测试

4. 禁止隐性耦合与循环依赖

规则

  • store 之间只通过方法调用/只读数据交互,不在 state 里保存对方的可变引用
  • 避免:A 在 setupuseB,B 又在 setupuseA
  • 把跨模块副作用放到门面层或独立 composable(如 useTradeBootstrap())里

反例

ts
// ❌ 不要这样做
export const useStoreA = defineStore("storeA", () => {
  const storeB = useStoreB(); // 在 setup 时就相互引用
  // ...
});

export const useStoreB = defineStore("storeB", () => {
  const storeA = useStoreA(); // 循环依赖
  // ...
});

正例

ts
// ✅ 在动作方法中按需调用
export const useStoreA = defineStore("storeA", () => {
  const doSomething = () => {
    const storeB = useStoreB(); // 在需要时才引用
    storeB.update();
  };
  return { doSomething };
});

模块依赖最佳实践:外用 barrel、内走相对

为了彻底杜绝循环依赖,遵循以下可落地的"小规则":

1. 单向依赖原则

  • 子模块 → 子模块:使用相对路径 import { useXxxStore } from './xxx''../xxx'
  • 门面(aggregate/facade) → 子模块:相对路径 './tabs.store'
  • 禁止 子模块 → barrel(会导致 A→barrel→A 自咬尾)

示例

ts
// ✅ 子模块之间:使用相对路径
// store/trade/tabs.store.ts
import { useTradeOrdersStore } from "./orders.store";

// ✅ 门面引用子模块:相对路径
// store/trade/index.ts
import { useTradeTabsStore } from "./tabs.store";
import { useTradeOrdersStore } from "./orders.store";

// ❌ 子模块引用 barrel:禁止!
// store/trade/tabs.store.ts
import { useTradeStore } from "@/store"; // 会造成循环依赖

2. barrel (index.ts) 最小化

  • export 门面:export * from './trade'
  • 不要在 barrel 里 export * from './trade/tabs.store'(暴露内部实现)
ts
// ✅ store/index.ts - 只导出门面
export { useAccountStore } from "./account";
export { useSymbolInfoStore } from "./symbol-info";
export { useTradeStore } from "./trade"; // 只导出聚合门面
export { useSocketStore } from "./socket";

// ❌ 不要导出内部模块
export { useTradeTabsStore } from "./trade/tabs.store"; // 不推荐

3. storeToRefs 正确姿势

ts
import { storeToRefs } from "pinia";

// ✅ 正确:先实例化,再解构
const accountStore = useAccountStore();
const { selectedAccount } = storeToRefs(accountStore);

// ❌ 错误:直接解构会丢失响应性
const { selectedAccount } = useAccountStore(); // 不是响应式的

4. 若必须跨域调用,优先经"服务层"

把流程编排放到 services/trade.service.ts,避免 store 之间互相 import 过多。

ts
// services/trade.service.ts
import { useAccountStore } from "@/store/account";
import { useTradeTabsStore } from "@/store/trade/tabs.store";
import { useSocketStore } from "@/store/socket";

export class TradeService {
  // 跨 store 的复杂流程编排
  async initializeTrade(symbol: string) {
    const accountStore = useAccountStore();
    const tabsStore = useTradeTabsStore();
    const socketStore = useSocketStore();

    // 编排多个 store 的动作
    await accountStore.ensureAccount();
    tabsStore.addTab({ symbol });
    socketStore.setPinned(symbol);
  }
}

export const tradeService = new TradeService();

5. 工具护栏(可选但推荐)

通过 ESLint 自动化执行"外用 barrel、内走相对"的约束,从源头避免循环依赖。

安装依赖

bash
# 复杂逻辑:import 规则 + TS 解析器 + boundaries 插件
pnpm add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
  eslint-plugin-import eslint-import-resolver-typescript \
  eslint-plugin-boundaries

完整 ESLint 配置

js
// .eslintrc.cjs
/* 复杂逻辑:基础解析与插件启用(TS + import + boundaries) */
module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  plugins: ["@typescript-eslint", "import", "boundaries"],
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:import/recommended",
    "plugin:import/typescript",
  ],
  settings: {
    /* 复杂逻辑:让 import 解析 TS 路径别名(含 tsconfig paths) */
    "import/resolver": {
      typescript: {
        // 对应你的 tsconfig.json(支持 "paths" 别名)
        project: "./tsconfig.json",
      },
      node: {
        extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
      },
    },

    /* 复杂逻辑:定义"边界元素类型",用于约束依赖方向 */
    "boundaries/elements": [
      // 整个 store 的对外聚合入口(barrel)
      { type: "store-barrel", pattern: "src/store/index.ts" },
      // 各领域门面(facade),如 src/store/trade/index.ts
      { type: "store-facade", pattern: "src/store/**/index.ts" },
      // 具体子模块实现(除 index.ts 外的 store 文件)
      { type: "store-module", pattern: "src/store/**/!(*index).ts" },
      // 其它层(按需添加)
      { type: "feature", pattern: "src/**/features/**" },
      { type: "ui", pattern: "src/**/components/**" },
    ],
  },
  rules: {
    /* 复杂逻辑:阻止循环依赖(忽略三方包) */
    "import/no-cycle": ["error", { ignoreExternal: true, maxDepth: 1 }],

    /* 复杂逻辑:确保上面 elements 定义能被识别 */
    "boundaries/no-unknown": "error",

    /* 复杂逻辑:默认禁止跨边界依赖,仅按规则白名单放行 */
    "boundaries/element-types": [
      "error",
      {
        default: "disallow",
        rules: [
          // 子模块之间可以互相依赖(按需保留/收紧)
          { from: ["store-module"], allow: ["store-module"] },

          // 门面可以依赖子模块(聚合/转发)
          { from: ["store-facade"], allow: ["store-module"] },

          // ❗子模块禁止依赖"总 barrel"(避免 A→barrel→A 自咬尾)
          { from: ["store-module"], disallow: ["store-barrel"] },

          // 其它层的约束(示例):UI 不能直接依赖 barrel(可选)
          { from: ["ui"], disallow: ["store-barrel"] },
        ],
      },
    ],

    /* 可选:统一扩展名策略,减少误判 */
    "import/extensions": [
      "error",
      "ignorePackages",
      {
        ts: "never",
        tsx: "never",
        js: "never",
        jsx: "never",
        mjs: "never",
      },
    ],
  },

  /* 可选但实用:严格禁止"子模块从 barrel 导入" */
  overrides: [
    {
      files: ["src/store/**/*.ts"],
      rules: {
        "no-restricted-imports": [
          "error",
          {
            paths: ["@/store", "@/store/index", "@/store/index.ts"],
            patterns: [
              {
                group: ["@/store"],
                message:
                  '子模块请使用相对路径或具体子模块路径导入,禁止从 "@/store"(barrel)导入。',
              },
            ],
          },
        ],
      },
    },
  ],
};

TypeScript 别名配置

json
// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/store": ["./src/store/index.ts"], // 仅对外层暴露
      "@/services/*": ["./src/services/*"]
    }
  }
}

package.json 脚本

json
{
  "scripts": {
    "lint": "eslint \"src/**/*.{ts,tsx,js,vue}\"",
    "lint:fix": "eslint \"src/**/*.{ts,tsx,js,vue}\" --fix"
  }
}

.eslintignore(可选)

gitignore
dist/
node_modules/
coverage/

违规示例与修正

ts
// ❌ 会命中 boundaries + no-restricted-imports
import { useAccountStore } from "@/store"; // barrel

// ✅ 用具体路径/相对路径,避免循环依赖
import { useAccountStore } from "@/store/account";
ts
// ❌ 子模块引用 barrel,极易形成 A→barrel→A
import { useTradeTabsStore } from "@/store";

// ✅ 门面引用子模块,用相对路径
import { useTradeTabsStore } from "./tabs.store";

规则说明

  • import/no-cycle:兜底阻止环依赖
  • boundaries:定义"元素类型 + 允许关系",从设计上约束依赖方向
  • no-restricted-imports:对"子模块 →barrel"的坏味道一刀切禁止

使用建议

  • 外部组件:使用别名 import { useTradeStore } from '@/store'
  • 内部模块:使用相对路径 import { useTabsStore } from './tabs.store',更直观且避免循环依赖

小结"外用 barrel、内走相对",再配合门面只做转发与编排,就能在大项目里稳住依赖关系与可维护性。


5. 事件/消息编排(可选)

大型团队会用轻量事件总线或「动作层」(如 services/tradeService.ts)来做跨 store 的复杂流程(下单 → 订阅 → 回补数据)。

好处

  • ✅ 把"过程"从 store 里抽离
  • ✅ store 专注"状态"
  • ✅ 业务流程可独立测试

6. 包/模块分层(Monorepo 可选)

超大项目会把领域 store 放到独立包: packages/state-account/state-trade/state-market/,应用项目只做聚合和 UI。

好处

  • ✅ 解耦团队协作与发布节奏
  • ✅ 可独立版本管理
  • ✅ 跨项目复用

目录结构示例

src/
  store/
    account/
      index.ts              # useAccountStore
    symbol-info/
      index.ts              # useSymbolInfoStore
    trade/
      index.ts              # useTradeStore - 聚合门面,只转发
      tabs.store.ts         # 具体功能模块
      orders.store.ts
      positions.store.ts
    socket/
      index.ts              # useSocketStore
  services/
    trade.service.ts        # 跨模块编排(可选)
  composables/
    useTradeBootstrap.ts    # 初始化/监听装配(可选)

聚合门面示例

ts
// store/trade/index.ts
import { useTradeTabsStore } from "./tabs.store";
import { useTradeOrdersStore } from "./orders.store";
import { useTradePositionsStore } from "./positions.store";

/**
 * Trade 模块聚合门面
 * 只做转发和跨模块编排,不包含具体业务逻辑
 */
export function useTradeStore() {
  const tabsStore = useTradeTabsStore();
  const ordersStore = useTradeOrdersStore();
  const positionsStore = useTradePositionsStore();

  // 转发各子模块的状态和方法
  return {
    // Tabs 相关
    tabs: tabsStore.tabs,
    selectedTab: tabsStore.selectedTab,
    updateActiveSymbol: tabsStore.updateActiveSymbol,

    // Orders 相关
    orders: ordersStore.orders,
    pendingOrders: ordersStore.pendingOrders,

    // Positions 相关
    positions: positionsStore.positions,

    // 跨模块编排方法
    reset() {
      tabsStore.reset();
      ordersStore.reset();
      positionsStore.reset();
    },
  };
}

Feature Store 示例

ts
// store/trade/tabs.store.ts
import { defineStore } from "pinia";
import { useSocketStore } from "@/store/socket";
import { useSymbolInfoStore } from "@/store/symbol-info";

export const useTradeTabsStore = defineStore("trade-tabs", () => {
  const socketStore = useSocketStore();
  const symbolInfoStore = useSymbolInfoStore();

  const tabs = ref<SymbolTabItem[]>([]);
  const selectedTab = ref<SymbolTabItem | null>(null);

  // 切换选中 Tab
  const updateActiveSymbol = (tab: SymbolTabItem) => {
    if (selectedTab.value?.symbol === tab.symbol) return;
    selectedTab.value = tab;

    // 跨模块协作:更新订阅和获取规格
    socketStore.setPinned(tab.symbol);
    symbolInfoStore.fetchSpecs(tab.symbol);
  };

  // 重置
  const reset = () => {
    tabs.value = [];
    selectedTab.value = null;
    socketStore.setPinned(null);
  };

  return {
    tabs,
    selectedTab,
    updateActiveSymbol,
    reset,
  };
});

测试与稳定性

单元测试

小 store 粒度更小、状态面更窄 → 单测更快

ts
// tabs.store.spec.ts
import { setActivePinia, createPinia } from "pinia";
import { useTradeTabsStore } from "./tabs.store";

describe("TradeTabsStore", () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  it("should update selected tab", () => {
    const store = useTradeTabsStore();
    const tab = { symbol: "EURUSD" /* ... */ };

    store.updateActiveSymbol(tab);

    expect(store.selectedTab).toBe(tab);
  });

  it("should reset all state", () => {
    const store = useTradeTabsStore();
    store.tabs = [{ symbol: "EURUSD" }];
    store.selectedTab = store.tabs[0];

    store.reset();

    expect(store.tabs).toEqual([]);
    expect(store.selectedTab).toBeNull();
  });
});

契约测试

关键动作(如下单、切换账户、订阅行情)做契约测试(输入/输出约定),避免重构时回归。

ts
// trade.service.contract.spec.ts
describe("Trade Service Contract", () => {
  it("should handle order flow correctly", async () => {
    // 给定:初始状态
    const initialState = {
      /* ... */
    };

    // 当:执行下单
    const result = await tradeService.placeOrder({
      symbol: "EURUSD",
      volume: 0.01,
      type: "BUY",
    });

    // 那么:应该满足契约
    expect(result).toMatchObject({
      orderId: expect.any(String),
      status: "pending" | "filled" | "rejected",
    });
  });
});

性能与可维护性

性能优化

  • ✅ 小 store 避免巨大的 state 导致无关组件重渲染
  • ✅ 热更新与代码分割更友好(按需引入某些 store)
  • ✅ 可以针对高频更新的 store 做单独优化

可维护性提升

  • ✅ 每个 store 职责单一,易于理解
  • ✅ 修改影响范围可控,降低回归风险
  • ✅ 新人上手成本低,可以逐个模块学习
  • ✅ 便于增量重构,不影响其他模块

迁移路径(渐进式重构)

如果你现有项目是巨型 store,可以这样渐进式重构:

第一步:识别领域边界

分析现有 store 的状态和方法,按业务领域分组:

现有 useTradeStore:
  - tabs 相关:tabs, selectedTab, updateActiveSymbol, ...
  - orders 相关:orders, pendingOrders, placeOrder, ...
  - positions 相关:positions, closePosition, ...

第二步:抽取 Feature Store

先抽取一个耦合最少的模块(如 tabs):

ts
// 1. 创建新的 tabs.store.ts
export const useTradeTabsStore = defineStore("trade-tabs", () => {
  // 把 tabs 相关的状态和方法搬过来
  // ...
});

// 2. 在原 trade store 中引用
export const useTradeStore = defineStore("trade", () => {
  const tabsStore = useTradeTabsStore();

  return {
    // 暂时保持原有 API,内部转发
    tabs: computed(() => tabsStore.tabs),
    selectedTab: computed(() => tabsStore.selectedTab),
    updateActiveSymbol: tabsStore.updateActiveSymbol,
    // ...
  };
});

第三步:逐步迁移调用方

ts
// 旧代码(暂时仍可工作)
const tradeStore = useTradeStore();
console.log(tradeStore.tabs);

// 新代码(逐步迁移)
const tabsStore = useTradeTabsStore();
console.log(tabsStore.tabs);

第四步:清理遗留代码

当所有调用方都迁移完成后,删除 trade store 中的转发代码,完成重构。


实战建议

1. 命名规范

  • 领域 store:useXxxStore,如 useAccountStore
  • Feature store:useXxxYyyStore,如 useTradeTabsStore
  • 聚合门面:保持简洁,如 useTradeStore

2. 状态共享原则

  • 不要在一个 store 的 state 中存储另一个 store 的引用
  • 不要在 store 之间传递响应式对象(会破坏单向数据流)
  • 应该通过方法调用来触发其他 store 的动作
  • 应该使用 computed 来派生跨 store 的状态

3. 初始化时机

复杂的跨 store 初始化逻辑,放到独立的 composable:

ts
// composables/useTradeBootstrap.ts
export function useTradeBootstrap() {
  const accountStore = useAccountStore();
  const socketStore = useSocketStore();
  const tabsStore = useTradeTabsStore();

  const initialize = async () => {
    await accountStore.fetchAccounts();
    await socketStore.connect();
    tabsStore.restoreFromLocalStorage();
  };

  onMounted(() => {
    initialize();
  });
}

4. 调试与监控

导出关键 store 的状态到 devtools:

ts
// store/index.ts
export function setupDevtools() {
  if (import.meta.env.DEV) {
    window.__STORES__ = {
      account: useAccountStore(),
      socket: useSocketStore(),
      tradeTabs: useTradeTabsStore(),
      // ...
    };
  }
}

结论

大型项目中的 Pinia Store 组织架构,推荐遵循:

  1. 按领域拆分:每个 store 单一职责
  2. 聚合门面:保持 API 稳定,内部持续演进
  3. Feature Store:页面复杂交互独立管理
  4. 避免循环依赖:在方法中按需调用其他 store
  5. 渐进式重构:从耦合最少的模块开始抽取

这种架构模式对团队协作和长期演进都更友好,是经过大厂验证的最佳实践。


相关资源

本站总访问